/**
 * @license
 * Copyright (C) 2019 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
const JSON_PREFIX = ')]}\'';

/**
 * Wrapper around Map for caching server responses. Site-based so that
 * changes to CANONICAL_PATH will result in a different cache going into
 * effect.
 */
export class SiteBasedCache {
  constructor() {
    // Container of per-canonical-path caches.
    this._data = new Map();
    if (window.INITIAL_DATA != undefined) {
      // Put all data shipped with index.html into the cache. This makes it
      // so that we spare more round trips to the server when the app loads
      // initially.
      Object
          .entries(window.INITIAL_DATA)
          .forEach(e => this._cache().set(e[0], e[1]));
    }
  }

  // Returns the cache for the current canonical path.
  _cache() {
    if (!this._data.has(window.CANONICAL_PATH)) {
      this._data.set(window.CANONICAL_PATH, new Map());
    }
    return this._data.get(window.CANONICAL_PATH);
  }

  has(key) {
    return this._cache().has(key);
  }

  get(key) {
    return this._cache().get(key);
  }

  set(key, value) {
    this._cache().set(key, value);
  }

  delete(key) {
    this._cache().delete(key);
  }

  invalidatePrefix(prefix) {
    const newMap = new Map();
    for (const [key, value] of this._cache().entries()) {
      if (!key.startsWith(prefix)) {
        newMap.set(key, value);
      }
    }
    this._data.set(window.CANONICAL_PATH, newMap);
  }
}

export class FetchPromisesCache {
  constructor() {
    this._data = {};
  }

  has(key) {
    return !!this._data[key];
  }

  get(key) {
    return this._data[key];
  }

  set(key, value) {
    this._data[key] = value;
  }

  invalidatePrefix(prefix) {
    const newData = {};
    Object.entries(this._data).forEach(([key, value]) => {
      if (!key.startsWith(prefix)) {
        newData[key] = value;
      }
    });
    this._data = newData;
  }
}

export class GrRestApiHelper {
  /**
   * @param {SiteBasedCache} cache
   * @param {object} auth
   * @param {FetchPromisesCache} fetchPromisesCache
   * @param {object} restApiInterface
   */
  constructor(cache, auth, fetchPromisesCache,
      restApiInterface) {
    this._cache = cache;// TODO: make it public
    this._auth = auth;
    this._fetchPromisesCache = fetchPromisesCache;
    this._restApiInterface = restApiInterface;
  }

  /**
   * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
   * with timing and logging.
   *
   * @param {Gerrit.FetchRequest} req
   */
  fetch(req) {
    const start = Date.now();
    const xhr = this._auth.fetch(req.url, req.fetchOptions);

    // Log the call after it completes.
    xhr.then(res => this._logCall(req, start, res ? res.status : null));

    // Return the XHR directly (without the log).
    return xhr;
  }

  /**
   * Log information about a REST call. Because the elapsed time is determined
   * by this method, it should be called immediately after the request
   * finishes.
   *
   * @param {Gerrit.FetchRequest} req
   * @param {number} startTime the time that the request was started.
   * @param {number} status the HTTP status of the response. The status value
   *     is used here rather than the response object so there is no way this
   *     method can read the body stream.
   */
  _logCall(req, startTime, status) {
    const method = (req.fetchOptions && req.fetchOptions.method) ?
      req.fetchOptions.method : 'GET';
    const endTime = Date.now();
    const elapsed = (endTime - startTime);
    const startAt = new Date(startTime);
    const endAt = new Date(endTime);
    console.log([
      'HTTP',
      status,
      method,
      elapsed + 'ms',
      req.anonymizedUrl || req.url,
      `(${startAt.toISOString()}, ${endAt.toISOString()})`,
    ].join(' '));
    if (req.anonymizedUrl) {
      this.dispatchEvent(new CustomEvent('rpc-log', {
        detail: {status, method, elapsed, anonymizedUrl: req.anonymizedUrl},
        composed: true, bubbles: true,
      }));
    }
  }

  /**
   * Fetch JSON from url provided.
   * Returns a Promise that resolves to a native Response.
   * Doesn't do error checking. Supports cancel condition. Performs auth.
   * Validates auth expiry errors.
   *
   * @param {Gerrit.FetchJSONRequest} req
   */
  fetchRawJSON(req) {
    const urlWithParams = this.urlWithParams(req.url, req.params);
    const fetchReq = {
      url: urlWithParams,
      fetchOptions: req.fetchOptions,
      anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
    };
    return this.fetch(fetchReq)
        .then(res => {
          if (req.cancelCondition && req.cancelCondition()) {
            res.body.cancel();
            return;
          }
          return res;
        })
        .catch(err => {
          if (req.errFn) {
            req.errFn.call(undefined, null, err);
          } else {
            this.dispatchEvent(new CustomEvent('network-error', {
              detail: {error: err},
              composed: true, bubbles: true,
            }));
          }
          throw err;
        });
  }

  /**
   * Fetch JSON from url provided.
   * Returns a Promise that resolves to a parsed response.
   * Same as {@link fetchRawJSON}, plus error handling.
   *
   * @param {Gerrit.FetchJSONRequest} req
   * @param {boolean} noAcceptHeader - don't add default accept json header
   */
  fetchJSON(req, noAcceptHeader) {
    if (!noAcceptHeader) {
      req = this.addAcceptJsonHeader(req);
    }
    return this.fetchRawJSON(req).then(response => {
      if (!response) {
        return;
      }
      if (!response.ok) {
        if (req.errFn) {
          req.errFn.call(null, response);
          return;
        }
        this.dispatchEvent(new CustomEvent('server-error', {
          detail: {request: req, response},
          composed: true, bubbles: true,
        }));
        return;
      }
      return response && this.getResponseObject(response);
    });
  }

  /**
   * @param {string} url
   * @param {?Object|string=} opt_params URL params, key-value hash.
   * @return {string}
   */
  urlWithParams(url, opt_params) {
    if (!opt_params) { return this.getBaseUrl() + url; }

    const params = [];
    for (const p in opt_params) {
      if (!opt_params.hasOwnProperty(p)) { continue; }
      if (opt_params[p] == null) {
        params.push(encodeURIComponent(p));
        continue;
      }
      for (const value of [].concat(opt_params[p])) {
        params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
      }
    }
    return this.getBaseUrl() + url + '?' + params.join('&');
  }

  /**
   * @param {!Object} response
   * @return {?}
   */
  getResponseObject(response) {
    return this.readResponsePayload(response)
        .then(payload => payload.parsed);
  }

  /**
   * @param {!Object} response
   * @return {!Object}
   */
  readResponsePayload(response) {
    return response.text().then(text => {
      let result;
      try {
        result = this.parsePrefixedJSON(text);
      } catch (_) {
        result = null;
      }
      return {parsed: result, raw: text};
    });
  }

  /**
   * @param {string} source
   * @return {?}
   */
  parsePrefixedJSON(source) {
    return JSON.parse(source.substring(JSON_PREFIX.length));
  }

  /**
   * @param {Gerrit.FetchJSONRequest} req
   * @return {Gerrit.FetchJSONRequest}
   */
  addAcceptJsonHeader(req) {
    if (!req.fetchOptions) req.fetchOptions = {};
    if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
    if (!req.fetchOptions.headers.has('Accept')) {
      req.fetchOptions.headers.append('Accept', 'application/json');
    }
    return req;
  }

  getBaseUrl() {
    return this._restApiInterface.getBaseUrl();
  }

  dispatchEvent(type, detail) {
    return this._restApiInterface.dispatchEvent(type, detail);
  }

  /**
   * @param {Gerrit.FetchJSONRequest} req
   */
  fetchCacheURL(req) {
    if (this._fetchPromisesCache.has(req.url)) {
      return this._fetchPromisesCache.get(req.url);
    }
    // TODO(andybons): Periodic cache invalidation.
    if (this._cache.has(req.url)) {
      return Promise.resolve(this._cache.get(req.url));
    }
    this._fetchPromisesCache.set(req.url,
        this.fetchJSON(req)
            .then(response => {
              if (response !== undefined) {
                this._cache.set(req.url, response);
              }
              this._fetchPromisesCache.set(req.url, undefined);
              return response;
            })
            .catch(err => {
              this._fetchPromisesCache.set(req.url, undefined);
              throw err;
            })
    );
    return this._fetchPromisesCache.get(req.url);
  }

  /**
   * Send an XHR.
   *
   * @param {Gerrit.SendRequest} req
   * @return {Promise}
   */
  send(req) {
    const options = {method: req.method};
    if (req.body) {
      options.headers = new Headers();
      options.headers.set(
          'Content-Type', req.contentType || 'application/json');
      options.body = typeof req.body === 'string' ?
        req.body : JSON.stringify(req.body);
    }
    if (req.headers) {
      if (!options.headers) { options.headers = new Headers(); }
      for (const header in req.headers) {
        if (!req.headers.hasOwnProperty(header)) { continue; }
        options.headers.set(header, req.headers[header]);
      }
    }
    const url = req.url.startsWith('http') ?
      req.url : this.getBaseUrl() + req.url;
    const fetchReq = {
      url,
      fetchOptions: options,
      anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
    };
    const xhr = this.fetch(fetchReq)
        .then(response => {
          if (!response.ok) {
            if (req.errFn) {
              return req.errFn.call(undefined, response);
            }
            this.dispatchEvent(new CustomEvent('server-error', {
              detail: {request: fetchReq, response},
              composed: true, bubbles: true,
            }));
          }
          return response;
        })
        .catch(err => {
          this.dispatchEvent(new CustomEvent('network-error', {
            detail: {error: err},
            composed: true, bubbles: true,
          }));
          if (req.errFn) {
            return req.errFn.call(undefined, null, err);
          } else {
            throw err;
          }
        });

    if (req.parseResponse) {
      return xhr.then(res => this.getResponseObject(res));
    }

    return xhr;
  }

  /**
   * @param {string} prefix
   */
  invalidateFetchPromisesPrefix(prefix) {
    this._fetchPromisesCache.invalidatePrefix(prefix);
    this._cache.invalidatePrefix(prefix);
  }
}
